All files / web/src/app/api/abacus/print/printers/[id]/jobs route.ts

92.59% Statements 75/81
75% Branches 12/16
100% Functions 0/0
92.59% Lines 75/81

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 821x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 4x 1x 1x 1x 1x 1x 3x 3x 3x 3x 4x     3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 4x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x     2x 3x 3x 4x     4x  
/**
 * POST /api/abacus/print/printers/[id]/jobs (Abacus Studio #8.3) — job submit.
 *
 * Multipart pass-through (`model` = 3MF, `job` = v2 ticket JSON) with exactly
 * ONE mutation: `job.source.app = "abacus-studio"`. Everything else —
 * `style` above all — forwards untouched (v2 discipline: the proxy never
 * injects, normalizes, or defaults ticket fields; mixing vocabularies is the
 * service's 400 to give). On success the job's ownership row is recorded so
 * `jobs/*` reads and doorbell rings can be authorized.
 */
import { NextResponse } from 'next/server'
import { withAuth } from '@/lib/auth/withAuth'
import { getUserId } from '@/lib/viewer'
import { resolveConnection } from '@/lib/abacus/print/connections'
import { recordJobOwnership } from '@/lib/abacus/print/job-ownership'
import { proxyErrorResponse, relayResponse } from '@/lib/abacus/print/proxy'
import { printServiceFetch } from '@/lib/abacus/print/print-service-fetch'
import { PRINT_SOURCE_APP } from '@/lib/abacus/print/source-app'
 
/** Generous ceiling for the multipart upload (abacus 3MFs are MB-scale). */
const SUBMIT_TIMEOUT_MS = 120_000
 
export const POST = withAuth(async (request, { params }) => {
  const { id: printerId } = (await params) as { id: string }
  try {
    const userId = await getUserId()
    const connection = await resolveConnection(
      userId,
      request.nextUrl.searchParams.get('connectionId')
    )
 
    const form = await request.formData().catch(() => null)
    const model = form?.get('model')
    const jobRaw = form?.get('job')
    if (!form || !(model instanceof File) || typeof jobRaw !== 'string') {
      return NextResponse.json(
        { error: 'Submit requires multipart fields "model" (file) and "job" (JSON)' },
        { status: 400 }
      )
    }
 
    let job: Record<string, unknown>
    try {
      job = JSON.parse(jobRaw)
    } catch {
      return NextResponse.json({ error: '"job" field is not valid JSON' }, { status: 400 })
    }
 
    // The only mutation the proxy is allowed to make.
    job.source = { ...(job.source as Record<string, unknown> | undefined), app: PRINT_SOURCE_APP }
 
    const upstreamForm = new FormData()
    upstreamForm.set('model', model, model.name)
    upstreamForm.set('job', JSON.stringify(job))
 
    const upstream = await printServiceFetch(
      { origin: connection.origin, tokenSealed: connection.tokenSealed },
      `/printers/${encodeURIComponent(printerId)}/jobs`,
      { method: 'POST', body: upstreamForm, timeoutMs: SUBMIT_TIMEOUT_MS }
    )
 
    if (upstream.ok) {
      const body = (await upstream
        .clone()
        .json()
        .catch(() => null)) as { jobId?: unknown; id?: unknown; job?: { id?: unknown } } | null
      const jobId = [body?.jobId, body?.id, body?.job?.id].find(
        (v): v is string => typeof v === 'string' && v.length > 0
      )
      if (jobId) {
        await recordJobOwnership({ jobId, connectionId: connection.id, userId, printerId })
      } else {
        console.error('[print-proxy] submit accepted but no job id found in response')
      }
    }
 
    return relayResponse(upstream)
  } catch (error) {
    return proxyErrorResponse(error)
  }
})